iOS アプリの位置情報から AWS Lambda で GeoHash を作成するサーバーレスアプリケーション
こんな構成のやつを作ります。
動機
位置情報を扱う場合、素の緯度・経度のままでは扱いにくいことがあります。例えば、
- 特定の範囲に存在するデバイス一覧を抽出したい
- ある店舗の周辺にあるデバイスを検知したい
といったユースケースです。手間と精度の相談になりますが、いくつか方法があります。
- 頑張って計算する
- MySQL の geometry型を使う
- Elasticsearch の Geolocation を使う
- GeoHash を計算し、ハッシュで抽出する
このうち、精度はやや落ちるものの、特定のデーターベースに依存せす、文字列で表現でき、サーバーレスと相性の良さそうなGeoHashを計算してみます。GeoHashが計算できれば、保存されたGeoHashと一致する緯度経度が抽出できそうです。以下の手順で進めます。
- サンプル iOS アプリケーションを用意する
- アプリで位置情報が取得できるようにする
- アプリで AWSの認証トークンを取得できるようにする
- Kinesis Streams とつなぎ、位置情報を送信する
- GeoHash を計算する Lambda Function
サンプル iOS アプリケーションを用意する
AWS が提供してくれているサンプルアプリケーションを使います。
こちらのブログでも利用したアプリです。ForkしてPUSH通知の周りを修正した分をGitHubにあげていますのでそれも利用できます。
- aws-samples/aws-mobile-ios-notes-tutorial (本家)
- cm-wada-yusuke/aws-mobile-ios-notes-tutorial(Amazon Pinpointと連携済み)
cloneして、pod install
後、Xcode で開いてください。
アプリで位置情報が取得できるようにする
LocationManager の実装
こちらのブログを参考に、位置情報を取得する LocationManager を実装していきます。
import UIKit import CoreLocation class LocationManager: NSObject { var locationManager: CLLocationManager? var analyticsService: AnalyticsService? // Amazon Pinpoint の endpointId=デバイス識別ID をキーとして利用するため init(analyticsService: AnalyticsService) { let lm = CLLocationManager() lm.desiredAccuracy = kCLLocationAccuracyBestForNavigation lm.distanceFilter = 100 lm.pausesLocationUpdatesAutomatically = true lm.allowsBackgroundLocationUpdates = true // バックグラウンドでの位置情報取得を有効にする lm.activityType = .fitness self.locationManager = lm self.analyticsService = analyticsService } func monitoring() { if !CLLocationManager.significantLocationChangeMonitoringAvailable() { return } locationManager!.delegate = self locationManager!.startMonitoringSignificantLocationChanges() locationManager!.startUpdatingLocation() } private func stream(location: CLLocationCoordinate2D) { // Amazon Pinpoint の endpointId を取得する // Amazon Pinpoint を利用しない場合は、デバイストークンなどで代用してください guard let endpointId = analyticsService?.getEndpointId() else { return } // シミュレーターで動作確認するためデバイストークンは取得できない前提で実装します let deviceToken = analyticsService?.getDeviceToken() ?? "" let location = Location( date: NSDate().timeIntervalSince1970 * 1000, latitude: String(location.latitude), longitude: String(location.longitude), endpointId: endpointId, deviceToken: deviceToken ) do { let jsonData = try JSONEncoder().encode(location) var jsonDataString = String(data: jsonData, encoding: .utf8) print("location: \(jsonDataString)") // いったん表示するだけにとどめます } catch { print("dispatch location error \(error)") } } } struct Location: Codable { let date: Double let latitude: String let longitude: String let endpointId: String let deviceToken: String private enum CodingKeys: String, CodingKey { case date case latitude case longitude case endpointId case deviceToken } } // LocationManager のデリゲート実装 extension LocationManager: CLLocationManagerDelegate { func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { if let current = locations.last { self.stream(location: current.coordinate) } } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { print("\(classForCoder) " + #function + "error: \(error)") } func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { switch status { case .authorizedAlways: break case .notDetermined: manager.requestAlwaysAuthorization() case .denied: print("denied") case .restricted: print("restricted") case .authorizedWhenInUse: break } } }
これを、AppDelegate で有効にします。
import UIKit import UserNotifications @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate { var window: UIWindow? var analyticsService: AnalyticsService? var locationManager: LocationManager? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { // 前略 ... // Initialize the analytics service // analyticsService = LocalAnalyticsService() analyticsService = AWSAnalyticsService() // Location Service locationManager = LocationManager.init(analyticsService: analyticsService!) locationManager?.monitoring() return true }
Info.plist を編集して ユーザーに許可を求める文言を設定
以下の設定を追加します。Property List 表示だとそれぞれ
- Privacy - Location When In Use Usage Description
- Privacy - Location Always and When In Use Usage Description
が対応します。
<key>NSLocationWhenInUseUsageDescription</key> <string>アプリ利用中に位置情報を利用します</string> <key>NSLocationAlwaysAndWhenInUseUsageDescription</key> <string>位置情報を利用します</string>
バックグラウンドの位置情報更新を有効にする
MyNoes > Capabilities > Background Modes > check Location updates
動作確認
シミュレータで起動し、位置情報が出力されることを確認します。
あとはこれを、print ではなく Kinesis Streams に流していくことになりますね。iOS アプリが AWS のサービスを使う場合、一時認証情報が必要になります。そのためには、アプリと Cognito Identity Pool を接続する必要があります。
アプリで AWSの認証トークンを取得できるようにする
ここからは、Mobile Hub で作成したAWSのバックエンドが必須になります。Mobile Hub を利用してアプリと連携する方法については、以下のブログを参照ください。
Cognito Identity Pool の ID を控える
Mobile Hub で Cognito Identity Pool を作成します。
AWS マネジメントコンソール > Cognito Identity Pool > Edit identity pool ここの identity pool ID を使います。
iOS アプリで、この identity pool ID を指定して Credential Provider、つまり一時認証情報を提供してくれる人を定義します。
アプリの AppDelegate で AWSCognitoCredentialsProvider
を登録する
platform :ios, '9.0' target 'MyNotes' do # Comment the next line if you're not using Swift and don't want to use dynamic frameworks use_frameworks! # Pods for MyNotes # Analytics dependency pod 'AWSPinpoint' pod 'AWSCognitoIdentityProvider' //追加 end
pod install
して AWSCognitoIdentityProvider
が利用できるようにします。
import UIKit import UserNotifications import AWSCognitoIdentityProvider @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, UISplitViewControllerDelegate { var window: UIWindow? var dataService: DataService? var analyticsService: AnalyticsService? var locationManager: LocationManager? var credentialsProvider: AWSCognitoCredentialsProvider? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { // 前略... // Initialize the credential provider. credentialsProvider = AWSCognitoCredentialsProvider( regionType: .APNortheast1, identityPoolId: "ap-northeast-1:xxxxxxxxxxxxxxxxxxxx" // 先程控えた Identity pool ID ) // AWS Servicve configuration. let serviceConfiguration = AWSServiceConfiguration(region: .APNortheast1, credentialsProvider: credentialsProvider) AWSServiceManager.default().defaultServiceConfiguration = serviceConfiguration // 中略... return true }
これでOKです。AWSServiceManager
に AWSCognitoCredentialsProvider
を登録しておくことで、あとはSDKが自動で一時認証情報を駆使してAWSサービスとやりとりしてくれます。
モバイルアプリが Kinesis Streams と接続することを許可する
一時認証情報を利用する準備が整いましたが、今回利用する Kinesis Streams と接続するためにはもう1ステップ必要です。AWSCognitoCredentialsProvider
で利用できる一時認証情報が2種類あり、
- 未認証のクレデンシャル
- 認証済みのクレデンシャル
があります。この仕組によって、「ログイン前は利用できないけど、ログイン後は利用できるようになるよ」といったことが実現できるわけです。今回のサンプルでは、認証済みのクレデンシャル は利用しません。アプリは必ず未認証のクレデンシャルを利用することになるため、未認証のクレデンシャルに紐づくIAMロールのポリシーで、Kinesis Streams を利用することを許可します。 ちなみにこのIAMロールは、Mobile Hub 経由で Cognito Identity Pool を設定すると自動で生成されるものです。
本当は利用する Kinesis Streams に絞ってPUTを許可するべきなのですが、簡単のためにフルアクセス権限を付与します。
Kinesis Streams とつなぎ、位置情報を送信する
ここまでできていれば、あとは先程 print
していた位置情報を、SDK を使って Kinesis Streams に送信するだけです。Kinesis Streams は後で作成します。
アプリ側 LocationManager 修正
作成した Kinesis Streams にデータを送信するよう修正します。
private func stream(location: CLLocationCoordinate2D) { guard let endpointId = analyticsService?.getEndpointId() else { return } let deviceToken = analyticsService?.getDeviceToken() ?? "" let location = Location( date: NSDate().timeIntervalSince1970 * 1000, latitude: String(location.latitude), longitude: String(location.longitude), endpointId: endpointId, deviceToken: deviceToken ) do { let jsonData = try JSONEncoder().encode(location) let recorder = AWSKinesisRecorder.default() recorder.saveRecord(jsonData, streamName: "itg-note-device-location-stream") .continueOnSuccessWith { task -> Any? in print("dispatch location: \(String(describing: jsonDataString))") return recorder.submitAllRecords() } .continueWith { task -> Any? in if let error = task.error { print("dispatch location error: \(error)") } return nil } } catch { print("dispatch location error \(error)") } }
GeoHash を計算する Lambda Function
ライブラリを利用すれば GeoHash の計算はそれほど難しくありません。今回は TypeScript で実装しました。Kinesis Streams から受け取ったデータから GeoHash を計算するクラスを用意します。
import { DateTime } from 'luxon'; import * as GeoHash from 'ngeohash'; export class DeviceLocation { public location: IDeviceLocation; constructor(location: IDeviceLocation) { this.location = location; } public geoHash9(): string { // 精度9(高い)のGeoHashを算出 return GeoHash.encode(this.location.latitude, this.location.longitude, 9); } public geoHash5(): string { // 精度5(低い)のGeoHashを算出 DynamoDB のGSIに設定して絞り込む目的で使う return GeoHash.encode(this.location.latitude, this.location.longitude, 5); } public deviceToken(): string { const t = this.location.deviceToken; return t ? t : 'empty'; } } export interface IDeviceLocation { endpointId: string; latitude: string; longitude: string; dispatchAt: DateTime; deviceToken?: string; }
これを使って、DynamoDB に保存します。
export class DeviceDynamodbTable { public static updateLocation(deviceLocation: DeviceLocation): Promise<void> { const params: UpdateItemInput = { TableName: NoteDeviceTableName, Key: {endpointId: {S: deviceLocation.location.endpointId}}, UpdateExpression: [ 'set latitude = :latitude', 'longitude = :longitude', 'geoHash5 = :geoHash5', 'geoHash9 = :geoHash9', 'dispatchAt = :dispatchAt', 'deviceToken = :deviceToken', 'updatedAt = :updatedAt', ].join(', '), ExpressionAttributeValues: { ':latitude': {S: deviceLocation.location.latitude}, ':longitude': {S: deviceLocation.location.longitude}, ':geoHash5': {S: deviceLocation.geoHash5()}, ':geoHash9': {S: deviceLocation.geoHash9()}, ':dispatchAt': {N: deviceLocation.location.dispatchAt.toMillis().toString()}, ':deviceToken': {S: deviceLocation.deviceToken()}, ':updatedAt': {N: DateUtil.jstNow().toMillis().toString()}, }, }; return DynamoDB.updateItem(params).promise() .then(() => { }); }
手前味噌で申し訳ないですが、Typescript の Lambda Function をデプロイするテンプレートを作成し、今回はそこからデプロイしています。位置情報を更新する今回のコードも含まれていますので、よかったら参考にしてみてください。
デプロイしたいAWSアカウントにスイッチロールの上、以下のようにしてデプロイします。
aws s3 mb s3://cm-itg-note-lambda-deploy # CloudFormationが利用するデプロイ用バケットの作成 make push-params env=itg ns=cm # Systems Manager のパラメータストアにパラメータを送信します make deploy-note env=itg ns=cm # パラメータストアの値を使って CloudFormation Deploy します
Kinesis Stream を作成する
Kinesis Streams と Lambda Function を接続するため、Kinesis Streams の CloudFormation テンプレートを修正し、再度デプロイします。
AWSTemplateFormatVersion: '2010-09-09' Resources: NoteDeviceLocationStream: Type: AWS::Kinesis::Stream Properties: Name: !Sub ${Env}-note-device-location-stream RetentionPeriodHours: 24 ShardCount: 1 NoteLambdaDeviceLocationEventSourceMapping: Type: AWS::Lambda::EventSourceMapping Properties: BatchSize: 100 Enabled: true EventSourceArn: !GetAtt NoteDeviceLocationStream.Arn FunctionName: !Sub ${Env}-note-transfer-location StartingPosition: LATEST
make infra-kinesis_streams env=itg ns=cm
DynamoDB を作成する
Kinesis Streams と同じように CloudForamation テンプレートを作成し、デプロイします。
AWSTemplateFormatVersion: '2010-09-09' Resources: NoteDeviceTable: Type: AWS::DynamoDB::Table Properties: TableName: !Ref NoteDeviceTableName AttributeDefinitions: - AttributeName: endpointId AttributeType: S - AttributeName: geoHash5 AttributeType: S - AttributeName: dispatchAt AttributeType: N KeySchema: - AttributeName: endpointId KeyType: HASH ProvisionedThroughput: ReadCapacityUnits: !Ref LocationTypeRcu WriteCapacityUnits: !Ref LocationTypeWcu GlobalSecondaryIndexes: - IndexName: geoHash5-index KeySchema: - AttributeName: geoHash5 KeyType: HASH - AttributeName: dispatchAt KeyType: RANGE Projection: ProjectionType: ALL ProvisionedThroughput: ReadCapacityUnits: !Ref LocationTypeRcu WriteCapacityUnits: !Ref LocationTypeWcu
make infra-dynamodb_tables env=itg ns=cm
実装完了です。
確認作業
アプリをシミュレータで起動します。まず、位置情報がPUTされ、Kinesis Streams の Monitoring から確認できればアプリからの送信はOKです。
次に、DynamoDBを確認します。
計算されたGeoHashとともに、DynamoDBへ保存されていることが確認できました。これを使って、精度5の GeoHash により global secondary index で絞り込んだ後、さらに精度9の GeoHash を使って filter をかけることで、GeoHashで表現される、特定の範囲内に存在するendpointIdを抽出する といったことができます。
まとめ
iOSアプリと Kinesis Streams をつないで、位置情報からGeoHashを算出して保存するサーバーレスアプリケーションを作成しました。保存されたGeoHashをもとに、「特定の範囲内にあるデバイスに対してPUSH通知を送る」といった芸当が可能です。別の記事で、Amazon Pinpoint を利用してこれをやってみようと思います。
AWS側はデータさえ送られてくれば計算・保存はそれほど難しくありませんでしたが、私がモバイルアプリに明るくないこともあり位置情報を取得して送信する部分で苦労しました。特に、AWSの未認証クレデンシャルを使うための処理に気づかず、Kinesis Streams へデータが送られず困っていまいした。この記事は備忘録として自分用に書いたところが大きいですが、誰かの参考になれば幸いです。